CS492 카이스트 전산학부 특강<FE 개발>
팀원: 김건우, 김진석, 유홍범, 이주훈, 한승희

코로나 19 사태로 인해 등교 수업이 제한되면서 온라인 수업과 오프라인 수업을 병행하는 블렌디드 러닝이 주목받고 있습니다. Blearn은 온라인과 오프라인의 장점을 결합한 블렌디드 러닝을 가능하게 하는 플랫폼입니다. Blearn에서는 사용자가 쉽게 수업을 생성 또는 가입하여 블렌디드 러닝 공간을 구성 및 참여할 수 있습니다. 오프라인 수업이 가진 이점들 중 두드러지는 것은 수업 중 공유된 학습 컨텐츠를 기반으로 원활한 상호 대화가 가능하다는 점입니다. 따라서 수업 내에서 동기화된 유튜브 영상 공유 및 제어와 음성/텍스트/이미지 채팅 기능을 구현했습니다. 또한 클래스 테마 변경, 파파고 API를 사용한 채팅 메시지 번역 등 학습에 용이한 기능을 구현했습니다.
다른 컴포넌트에서 공통적으로 사용할 수 있는 버튼과 사용자가 버튼의 색상을 조정할 수 있는 테마를 구현했습니다.
여러 페이지에서 공통적으로 사용할 수 있는 Dropdown과 Dialog를 구현했습니다.
알림 메세지를 띄울 수 있는 Toast를 구현했습니다.
자신과 상대의 텍스트 / 사진 채팅을 구현했습니다.
텍스트 / 사진을 입력할 수 있는 input 컴포넌트를 구현했습니다.
메인 페이지에 본인이 참여 중인 수업의 list와 수업에 참가하거나 수업을 생성할 수 있는 버튼을 구현했습니다.
수업에 참가하거나 수업을 생성하는 페이지를 구현했습니다.
수업 안에서 수업의 정보와 수업을 삭제하는 페이지를 구현했습니다.
다양한 컴포넌트에서 공통으로 사용될 인풋 컴포넌트를 구현했습니다.
헤더 구성
푸터 구성
교실 페이지에서 멤버와 음성 채팅 상태를 표시하는 컴포넌트를 구현했습니다.



프로필을 설정할 수 있는 컴포넌트를 구현했습니다.
음성 채팅을 사용할 때 나타나는 오디오 비주얼라이저를 구현했습니다.
방장이 컨트롤 할 수 있는 유튜브 플레이어를 구현하였습니다.
방 참가자가 사용할 수 있는 컨트롤러를 구현하였습니다.
처음 웹앱에 접속했을 때와 로그아웃 시 보이는 페이지입니다. 헤더의 로그인 버튼을 누르면 네이버 / 깃허브 로그인 API로 연결됩니다.

로그인 페이지입니다. 네이버 / 깃허브 계정으로 로그인 버튼을 눌러 로그인이 가능하고, 기존 사용자가 아닌 경우 가입 페이지로 연결됩니다.


메인 페이지입니다. 헤더에 있던 기존의 로그인 버튼이 사용자 계정 관리 / 테마 선택 버튼으로 변경됩니다. 사용자가 속한 수업의 list를 확인할 수 있고 수업 참가·생성하기 버튼으로 새로운 수업을 만들거나 참가할 수 있습니다.


헤더의 계정 관리 버튼에서 내 프로필 보기를 누르면 나타나는 프로필 변경 화면입니다. 프로필 사진과 사용자의 이름을 변경할 수 있고, 연결된 소셜 계정의 정보와 다른 소셜 계정을 연결할 수 있는 버튼이 있습니다.

교실 페이지입니다. 헤더에는 유튜브 영상 세팅 버튼과 시작·종료 버튼, 수업 설정 버튼이 있습니다. 수업 설정 화면에서는 수업 정보를 수정하거나, 수업을 삭제할 수 있습니다.
메인 화면에는 유튜브 플레이어와 채팅, 채팅 인풋이 존재하고 푸터에는 수업에 참여한 사람들의 프로필 사진과 음성 메세지를 전달할 수 있는 말하기 버튼이 있습니다.


웹앱의 전역 상태 관리를 위해 Recoil을 사용하였습니다. Recoil을 사용하면 atoms (공유 상태)에서 selectors (순수 함수)를 거쳐 React 컴포넌트로 내려가는 data-flow graph를 만들 수 있습니다. Atoms는 컴포넌트가 구독할 수 있는 상태의 단위입니다. Selectors는 atoms 상태값을 동기 또는 비동기 방식을 통해 변환합니다.
classroomsclassroomsAtom : 현재 접속한 유저의 모든 classroom을 가지고 있는 state입니다. 유저가 로그인 했을 때 User DB에서 classrooms 데이터를 불러와 세팅됩니다.
classroomsByHashSelector : classroomHash를 이용하여 classroom을 찾고 정보를 변경할 수 있도록 만들어 주는 selector입니다. 수업 이름/비밀번호 변경, 수업 비디오 변경, 말하고 있는 사람 변경 등에 사용됩니다.
classroomsNewSelector : 새로운 classroom을 추가해주는 selector입니다. 수업 참여/생성 등에 사용됩니다.
loadingloadingAtom : 현재 로딩 여부를 나타내는 state입니다. 페이지를 띄우기 전이나 로그인 페이지에서 로딩 상태를 나타낼 때 사용합니다.
mainClassroomHashStatemainClassroomHashAtom : 유저의 classroom 중 현재 focus되어있는 classroom의 Hash를 가지고 있는 state입니다. header, footer, voice chat 등에서 수업의 정보를 얻는 데 이용됩니다.
memeAtom : 유저의 정보를 가지고 있는 state입니다. 유저의 정보는 로그인 되었을 때 처음 세팅됩니다. 정보의 구성은 다음과 같습니다.
export interface MeInfo {
stringId: string;
displayName: string;
profileImage: string;
initialized: boolean;
ssoAccounts: SSOAccountJSON[];
}
meInfoSelector : 유저의 정보를 제공, 변경해주는 selector입니다. 유저의 정보가 필요한 모든 상황 또는 로그인이 된 바로 다음이나 profile 변경 시 사용됩니다.
meIdSelector : 유저의 고유한 id를 제공하는 selector입니다. 유저가 방장인지 학생인지 확인할 때 주로 사용됩니다.
meAddSSOAccountSelector : 소셜 계정을 추가할 때 사용하는 selector입니다.
meRemoveSSOAccountSelector : 소셜 계정을 삭제할 때 사용하는 selector입니다.
screenSizescreenSizeAtom : 다양한 스크린 사이즈에 대응하기 위한 state입니다. MobilePortrait/ MobileLandscape/Desktop으로의 화면 변경 시 사용됩니다.
export interface ScreenSize {
width: number;
viewportHeight: number;
actualHeight: number;
offset: number;
}
themethemeAtom : 웹앱의 색상 테마를 가지고 있는 state입니다. 테마의 종류는 violet, pink, green, blue이며, 이 값에 따라 Header, Footer, Logo, Button 등의 색상이 달라집니다.
toasttoastAtom : 알림 toast를 가지고 있는 state입니다.
toastNewSelector : 새로운 toast를 추가할 때 사용하는 selector입니다. BackEnd에서 오는 에러 메세지를 띄울 때 주로 사용합니다.
src/types/classroom.ts: 수업 하나를 나타내는 클래스입니다. 각 Classroom instance는 DB entity와 그 안에 있는 정보들을 property로 가지고 있고 추가로 현재 연결되어 있는 멤버, 임시적으로 접속이 끊긴 멤버, 음성 채팅 상태, 유튜브 공유 상태 등을 저장하고 있습니다.
src/managers/classroom.ts: 현재 멤버가 접속해 있는 상태인 수업을 나타내는 Classroom instance들을 관리하는 manager class입니다. ClassroomManager.classrooms가 hash를 Classroom instance로 연결시켜주는 map입니다.
src/managers/user.ts: 유저 정보 및 유저와 연결된 소켓을 관리하는 클래스입니다. 해당 유저의 main socket이 무엇인지 역시 저장하고 있습니다.req.session.toast?: string: 토스트 메시지의 배열을 JSON.stringify하여 저장한 세션 변수입니다. 유저가 토스트를 띄우려고 /toasts 루트에 접속하면 토스트를 전달하고 req.session.toast를 비웁니다.req.session.redirectUri?: string: 유저가 query params로 redirect_uri를 지정하면 redirectUri로 저장한 후 다시 React frontend 측으로 갈 때 redirect_uri를 query params에 붙여주고 req.session.redirectUri를 비웁니다.앱 내 여러 기능은 외부 API 사용을 필요로 합니다. 대표적으로 로그인, 유튜브 공유 및 제어, 채팅 번역, 채팅 이미지 메시지 기능에서 외부 API 호출을 필요로 합니다.
네이버 로그인 API를 사용하기 위해 오픈 API 사용 신청에서 앱 및 제공 정보 등록 후 로그인 API 키를 발급 받았습니다.
backend/src/routes/auth/naver.ts: 서버로 로그인 요청이 보내지면 Express 라우터가 /callback 루트로 전달하여 passport가 네이버 계정 인증을 진행합니다.
backend/src/passport.ts: callbackURL, clientID, client_secret 파라미터 선언 후 access_token을 사용하여 네이버에 요청을 보내 회원정보를 받아옵니다. 이후 callbackURL로 이동합니다. 기존 req.user가 있다면 기존 user entity에 SSOAccount를 추가합니다. 만약 없다면 Provider를 네이버로 지정 후 새로운 user entity를 만들어 SSOAccount를 추가합니다. 또한 이미 다른 계정에 연결되어 있는 소셜 계정과 연결을 거부합니다.
isAuthenticated, isAuthenticatedOrFail: 로그인 인증 확인 미들웨어 입니다.backend/src/routes/auth/logout.ts: 계정에서 로그아웃 요청이 보내지면 라우터가 로그인 전 페이지로 전달합니다.
GitHub OAuth를 사용하기 위해 로그인 API 키를 발급 받았습니다.
backend/src/routes/auth/github.ts: 서버로 로그인 요청이 보내지면 Express 라우터가 /callback 루트로 전달하여 passport가 GitHub 계정 인증을 진행합니다.
backend/src/passport.ts: callbackURL, clientID, client_secret 파라미터 선언 후 access_token을 사용하여 GitHub에 요청을 보내 회원정보를 받아옵니다. 이후 callbackURL로 이동합니다. 기존 req.user가 있다면 기존 user entity에 SSOAccount를 추가합니다. 만약 없다면 Provider를 GitHub로 지정 후 새로운 user entity를 만들어 SSOAccount를 추가합니다. 또한 이미 다른 계정에 연결되어 있는 소셜 계정과 연결을 거부합니다.
isAuthenticated, isAuthenticatedOrFail: 로그인 인증 확인 미들웨어 입니다.backend/src/routes/auth/logout.ts: 계정에서 로그아웃 요청이 보내지면 라우터가 로그인 전 페이지로 전달합니다.
수업 내 영상 공유와 제어를 위해 YouTube IFrame Player API를 react-youtube npm 패키지를 통해 사용했습니다.
frontend/src/components/youtube.ts: YouTube IFrame Player API에서 사용되는 값인 YouTube.PlayerState를 받아옵니다.
frontend/src/components/YTPlayer.tsx: videoId, onReady, onStateChange를 prop으로 받아 YouTube Player를 생성합니다.
frontend/src/components/YTPlayerControl.tsx: 사용자가 방장이면 기본 플레이어를 주어 재생/정지, 재생 시간 변경을 가능하도록 하고, 학생이면 플레이어 조작을 막고 커스텀한 컨트롤러를 플레이어에 추가합니다.
frontend/src/components/YTSynchronizer.tsx: 사용자가 방장이면 재생/정지 및 볼륨 제어를 가능한 prop을 전달하여 YTPlayer를 생성합니다. onReady, onStateChange 함수는 Recoil을 사용하여 상태변화가 있으면 동영상 정보를 소켓으로 emit합니다.
backend/src/io/YouTube.ts: 방장의 유튜브 재생 상태가 변하면 소켓이 상태변화를 emit하고, 성공하면 다른 멤버에게 broadcast합니다.
frontend/src/pages/ClassroomShare.tsx: ClassInstructorButton에서 "유튜브 영상 변경하기"를 클릭하면 검색 Dialog가 생성됩니다. 인풋 입력 시 useYouTubeSearch가 fetchAPI를 써서 YouTube에 search field에 query를 보내 영상 검색 결과를 받아옵니다. 받아온 영상 결과는 YouTubeItem으로 생성되어 Dialog에 나타납니다.
수업 내 채팅 메시지를 번역하여 보여주기 위하여 파파고 API를 사용했습니다. 해당 PR을 확인해주시길 바랍니다:
https://github.com/2021-fall-cs492c-team-10/monorepo/pull/228
프로필 사진 및 채팅 이미지를 업로드하기 위해 외부 서비스인 Imgur를 이용했습니다. Imgur API에서 클라이언트 키를 발급받았습니다.
backend/src/managers/image.ts: 이미지를 업로드나 삭제를 Imgur에 요청합니다.
backend/src/routes/users/me/index.ts: profileImageUploadResponse를 통해 이미지를 업로드 합니다. user entity의 profileImageDeleteHash 확인 후 이미지를 삭제합니다.
frontend/src/components/profile/ProfileSettingContent.tsx: PATCH /api/users/me로 이미지를 업로드 할 수 있습니다.
사진을 분석하여 연관된 단어를 제시해 주는 이미지 태깅 기능을 이용하려고 불러온 API입니다. 이 서비스는 유저가 올린 채팅 이미지에 자동으로 alt text를 붙여주는 기능을 구현하기 위하여 사용되었습니다.
backend/src/managers/image.ts: async ImageManager.getAltText(url: string)를 사용하여 이미지 태깅을 요청합니다. 성공하면 { en: string; ko: string }를, API 키가 모두 소진되면 null를 반환합니다.arrayBuffer.ts: ArrayBuffer를 이어붙이거나 subarray를 찾는 등의 util을 모아놓은 파일로, VoiceBuffer.ts에서 쓰입니다.clipboard.ts: copyTextToClipboard과 그 fallback을 정의하는 파일입니다. navigator.clipboard.writeText가 있으면 그 Promise를 이용하고, 아니면 textarea를 생성해서 copy 명령을 exec하는 방식을 이용합니다.date.ts: 날짜 및 시간과 관련된 함수를 모아 놓았으며, 채팅 등에서 날짜를 표시하거나 테스트할 때 쓰입니다.fetch.ts: typesafe한 REST API fetch 함수인 fetchAPI를 export합니다.history.ts: 브라우저 히스토리를 한번 더 wrapping하는 앱 히스토리 클래스입니다. 모바일의 백 버튼 등으로 인해 히스토리가 꼬이는 상황을 방지하기 위해 브라우저 built-in history를 그대로 이용하지 않고 커스텀 히스토리를 만들어 관리합니다.math.ts: range, random___, newtonRaphson, clamp 등 기본적인 수학적 함수를 정의합니다. 이 함수들은 테스트, wave visualizer 등 많은 파일에서 쓰입니다.style.ts: mergeClassNames, mergeStyles, conditionalClassName, conditionalStyle 등 복잡한 스타일링을 간편히 구현하기 위한 util들을 정의합니다.VoiceBuffer.ts: AudioBuffer들을 wrapping하는 클래스로, 음성 채팅을 구현하는 데에 쓰입니다. ArrayBuffer 형태로 오는 오디오 파일의 바이너리 헤더 처리나 여러 개의 AudioBuffer를 끊김 없이 자연스럽게 연결하기 위한 메소드 등이 정의되어 있습니다.waveVisualizer.ts: WaveVisualizer.tsx에 쓰이는 함수를 구현하는 파일로, frequency와 amplitude를 받아 적당히 랜덤한 iOS 9 시리 스타일과 비슷한 SVG paths 여러 개를 만드는 함수가 메인입니다.classroom.ts: 수업의 unique한 해시 (XaX-XaX-XaX 꼴)를 생성해 주는 함수 generateClassroomHash를 정의합니다.아래 API specification 섹션에서 설명하는 REST API와 Socket.IO interface의 request/response 타입 및 프론트엔드와 백엔드에서 공통적으로 쓰이는 타입들을 공통 라이브러리인 @team-10/lib으로 빼서 라이브러리화 하였습니다.
백엔드 서버에서 사용하는 REST API로, prefix로 /api가 붙어 있습니다. Endpoint 타입으로 HTTP method와 경로를 concatenate한 string을 지정해 두었고, 이에 따라 path parameters와 body 타입이 strict하게 결정됩니다. (Query parameters는 높은 자유도를 위해 제한을 두지는 않았습니다.)
예를 들어, 아래는 /api/users/me/sso-accounts 아래에 있는 route들에 대한 REST API 타입입니다.
import { SSOAccountJSON } from '..';
import { Empty, Response } from '../..';
export type UsersMeSSOAccountsEndpoints =
| 'GET /users/me/sso-accounts'
| 'GET /users/me/sso-accounts/:provider'
| 'DELETE /users/me/sso-accounts/:provider';
export type UsersMeSSOAccountsPathParams = {
'GET /users/me/sso-accounts': Empty;
'GET /users/me/sso-accounts/:provider': { provider: string };
'DELETE /users/me/sso-accounts/:provider': { provider: string };
};
export type UsersMeSSOAccountsRequestBodyType = {
'GET /users/me/sso-accounts': Empty;
'GET /users/me/sso-accounts/:provider': Empty;
'DELETE /users/me/sso-accounts/:provider': Empty;
};
export type UsersMeSSOAccountsResponseType = {
'GET /users/me/sso-accounts': UsersMeSSOAccountsGetResponse;
'GET /users/me/sso-accounts/:provider': UsersMeSSOAccountsProviderGetResponse;
'DELETE /users/me/sso-accounts/:provider': UsersMeSSOAccountsProviderDeleteResponse;
};
Path parameters의 경우에는 'GET /users/me/sso-accounts/:provider'와 같이 colon-prefixed parameter name을 넣어 두었고, 그에 해당하는 PathParams 타입이 정의되어 있습니다. Request body와 response body 역시 각각의 endpoint에 대해 정의되어 있습니다.
REST API에서 공통적으로 사용되는 타입들입니다.
Empty: empty object입니다.Response<P, E extends ResponseError>: REST API 응답 타입입니다. 성공하면 { success: true, payload }를 주고 실패하면 { success: false, error }를 줍니다.ResponseError: 에러 코드, 상태 코드, 기타 정보를 담고 있는 에러 타입입니다.UnauthorizedError와 InternalServerError: 언제든 발생할 수 있는 기본적인 두 에러로서 모든 응답에 기본적으로 포함되어 있습니다.SSOAccountJSON: 소셜 로그인 기능에 사용되는 SSO 계정 정보 타입입니다. 유저 한 명 당 같은 provider의 계정은 하나만 사용할 수 있습니다.UserInfoJSON: 유저를 프론트엔드 화면에 그리기 위한 최소한의 정보입니다./// @team-10/lib/src/rest
export type Empty = Record<string, never>;
export type Response<P, E extends ResponseError> = SuccessResponse<P> | FailureResponse<E>;
export interface SuccessResponse<P> {
success: true;
payload: P;
}
export interface FailureResponse<E extends ResponseError> {
success: false;
error: UnauthorizedError | InternalServerError | E;
}
export interface ResponseError {
code: string;
statusCode: number;
extra: Record<string, any>;
}
export interface UnauthorizedError extends ResponseError {
code: 'UNAUTHORIZED';
statusCode: 401;
extra: Empty;
}
export interface InternalServerError extends ResponseError {
code: 'INTERNAL_SERVER_ERROR';
statusCode: 500;
extra: {
details?: string;
}
}
export const unauthorizedError: UnauthorizedError = {
code: 'UNAUTHORIZED',
statusCode: 401,
extra: {},
};
// @team-10/lib/src/rest/users
export const providers = ['naver' as 'naver', 'github' as 'github'];
export type Provider = typeof providers extends (infer T)[] ? T : never;
export interface SSOAccountJSON {
provider: Provider;
providerId: string;
}
export interface UserInfoJSON {
stringId: string;
displayName: string;
profileImage: string;
}
/users/me (Authenticated)자신에 대한 정보를 받거나 수정하는 route입니다.
INVALID_INFORMATION: 올바르지 않은 body가 들어왔을 때 반환하는 에러 코드입니다.UNSUPPORTED_PROVIDER: 지원하는 OAuth provider (i.e., 네이버와 GitHub) 외에 다른 provider가 들어왔을 때 반환하는 에러 코드입니다.NONEXISTENT_SSO_ACCOUNT: 존재하지 않는 소셜 로그인 계정에 대해 GET이나 DELETE 요청이 왔을 때 반환하는 에러 코드입니다.UNIQUE_SSO_ACCOUNT: 유일한 소셜 로그인 계정은 지울 수 없으므로, 이때 DELETE 요청이 오면 반환하는 에러 코드입니다./// @team-10/lib/src/rest/users/me
export type UsersMeEndpoints =
| UsersMeSSOAccountsEndpoints
| 'GET /users/me'
| 'PATCH /users/me'
| 'DELETE /users/me';
export type UsersMePathParams = UsersMeSSOAccountsPathParams & {
'GET /users/me': Empty;
'PATCH /users/me': Empty;
'DELETE /users/me': Empty;
};
export type UsersMeRequestBodyType = UsersMeSSOAccountsRequestBodyType & {
'GET /users/me': Empty;
'PATCH /users/me': Partial<UserInfoJSON>;
'DELETE /users/me': Empty;
};
export type UsersMeResponseType = UsersMeSSOAccountsResponseType & {
'GET /users/me': UsersMeGetResponse;
'PATCH /users/me': UsersMePatchResponse;
'DELETE /users/me': UsersMeDeleteResponse;
};
// GET /users/me
type UsersMeGetResponse = Response<UserInfoMeJSON, never>;
// PATCH /users/me
export type UsersMePatchResponse = Response<UserInfoJSON, UsersMePatchError>;
export type UsersMePatchError = {
code: 'INVALID_INFORMATION';
statusCode: 400;
extra: {
field: string;
details: string;
};
};
// DELETE /users/me
type UsersMeDeleteResponse = Response<Empty, never>;
export interface UserInfoMeJSON extends UserInfoJSON {
initialized: boolean;
ssoAccounts: SSOAccountJSON[];
classrooms: ClassroomJSON[];
}
/// @team-10/lib/src/rest/users/me/sso-accounts
import { SSOAccountJSON } from '..';
import { Empty, Response } from '../..';
export type UsersMeSSOAccountsEndpoints =
| 'GET /users/me/sso-accounts'
| 'GET /users/me/sso-accounts/:provider'
| 'DELETE /users/me/sso-accounts/:provider';
export type UsersMeSSOAccountsPathParams = {
'GET /users/me/sso-accounts': Empty;
'GET /users/me/sso-accounts/:provider': { provider: string };
'DELETE /users/me/sso-accounts/:provider': { provider: string };
};
export type UsersMeSSOAccountsRequestBodyType = {
'GET /users/me/sso-accounts': Empty;
'GET /users/me/sso-accounts/:provider': Empty;
'DELETE /users/me/sso-accounts/:provider': Empty;
};
export type UsersMeSSOAccountsResponseType = {
'GET /users/me/sso-accounts': UsersMeSSOAccountsGetResponse;
'GET /users/me/sso-accounts/:provider': UsersMeSSOAccountsProviderGetResponse;
'DELETE /users/me/sso-accounts/:provider': UsersMeSSOAccountsProviderDeleteResponse;
};
// GET /users/me/sso-accounts
export type UsersMeSSOAccountsGetResponse = Response<SSOAccountJSON[], never>;
// GET /users/me/sso-accounts/:provider
export type UsersMeSSOAccountsProviderGetResponse
= Response<SSOAccountJSON, UsersMeSSOAccountsProviderGetError>;
export type UsersMeSSOAccountsProviderGetError = {
code: 'UNSUPPORTED_PROVIDER';
statusCode: 400;
extra: Empty;
} | {
code: 'NONEXISTENT_SSO_ACCOUNT';
statusCode: 404;
extra: Empty;
};
// DELETE /users/me/sso-accounts/:provider
export type UsersMeSSOAccountsProviderDeleteResponse
= Response<Empty, UsersMeSSOAccountsProviderDeleteError>;
export type UsersMeSSOAccountsProviderDeleteError = {
code: 'UNSUPPORTED_PROVIDER';
statusCode: 400;
extra: Empty;
} | {
code: 'NONEXISTENT_SSO_ACCOUNT';
statusCode: 400;
extra: Empty;
} | {
code: 'UNIQUE_SSO_ACCOUNT';
statusCode: 400;
extra: Empty;
};
/users/:othersId (Authenticated)다른 유저의 정보를 가져오는 route입니다. UserInfoJSON에 더해 자신과 공통으로 속해 있는 수업을 반환합니다.
NONEXISTENT_USER: 존재하지 않는 user id에 대해 GET 요청이 들어왔을 때 반환하는 에러 코드입니다./// @team-10/lib/src/rest/users/other
import { Empty, Response } from '..';
import { ClassroomJSON } from '../../classroom';
import { UserInfoJSON } from '.';
export type UsersOtherEndpoints = 'GET /users/:id';
export type UsersOtherPathParams = {
'GET /users/:id': { id: string };
};
export type UsersOtherRequestBodyType = {
'GET /users/:id': Empty;
};
export type UsersOtherResponseType = {
'GET /users/:id': UsersOtherGetResponse;
};
/* GET /users/:id */
export interface UserInfoOtherJSON extends UserInfoJSON {
commonClassrooms: ClassroomJSON[];
}
export type UsersOtherGetResponse = Response<UserInfoOtherJSON, UserInfoOtherGetError>;
export type UserInfoOtherGetError = {
code: 'NONEXISTENT_USER';
statusCode: 404;
extra: Empty;
};
/toasts페이지 redirection을 고려해, 페이지가 다시 로드될 때 세션에 담겨 있던 토스트 메시지를 가져와서 렌더하기 위한 route입니다. 세션에 담겨 있던 토스트는 클라이언트가 이 경로로 접속하면 토스트 (Toast[])를 반환하고 세션에서 토스트를 비웁니다.
/// @team-10/lib/src/rest/toasts
import { Response, Empty } from '.';
export type ToastsEndpoints =
| 'GET /toasts';
export type ToastsPathParams = {
'GET /toasts': Empty;
};
export type ToastsRequestBodyType = {
'GET /toasts': Empty;
};
export type ToastsResponseType = {
'GET /toasts': ToastsGetResponse;
};
interface Toast {
type: 'info' | 'warn' | 'error';
message: string;
}
type ToastsGetResponse = Response<Toast[], never>;
/translate (Authenticated)파파고 번역을 이용하기 위한 route입니다. 채팅 메시지에 대해서만 번역을 진행하며, 채팅 메시지의 uuid를 받아 대응되는 번역된 메시지를 반환합니다.
GET /translate: { chatId: string }
chatId 번역하려는 채팅의 UUID입니다.import { Response, Empty } from '.';
export type TranslateEndpoints =
| 'GET /translate';
export type TranslatePathParams = {
'GET /translate': Empty;
};
export type TranslateRequestBodyType = {
'GET /translate': Empty;
};
export type TranslateResponseType = {
'GET /translate': TranslateGetResponse;
};
type TranslateGetResponse = Response<{ message: string }, never>;
/youtube (Authenticated)YouTube 검색 API를 이용하기 위한 route입니다.
GET /translate: { q: string, pageToken?: string }
q 검색어입니다.pageToken 검색 결과가 여러 페이지일 때 다음 페이지를 조회하기 위한 token입니다. 이전 response의 body에 담겨 있습니다. (YouTube API의 일부)import { Response, Empty } from '.';
export type YouTubeEndpoints =
| 'GET /youtube';
export type YouTubePathParams = {
'GET /youtube': Empty;
};
export type YouTubeRequestBodyType = {
'GET /youtube': Empty;
};
export type YouTubeResponseType = {
'GET /youtube': YouTubeGetResponse;
};
export type YouTubeGetResponse = Response<{
result: YouTubeVideoDescription[];
nextPageToken?: string;
}, YouTubeGetError>;
export interface YouTubeVideoDescription {
thumbnail: string;
title: string;
publishedAt: string; // ISO string
creator: string;
video: { type: 'single' | 'playlist'; id: string };
}
export type YouTubeGetError = {
code: 'INVALID_INFORMATION';
statusCode: 400;
extra: {
field: string;
details: string;
};
};
Socket.IO API의 이벤트 payload 타입은 세 가지로 나누었습니다:
Request 클라이언트의 요청입니다.Response 클라이언트의 요청에 대한 서버의 응답입니다.Broadcast 서버에서 요청을 보내지 않은 클라이언트에게 보내는 메시지입니다.소켓의 입장에서 Response와 Broadcast는 구분되지 않기 때문에, namespace Socket___.Events에는 이 둘을 통합하여 Response로 export합니다.
Socket.IO 기본 내장 namespace는 사용하지 않으며 (client socket을 여러 개 저장해야 하는 상황을 피하기 위해) 따라서 각 event name 앞에 prefix classroom/, voice/ 등을 붙여 구분합니다.
ClassroomHash 수업의 UUID 역할을 하는 string입니다. UX 향상을 위해 classroom hash는 읽기 쉽게 되어 있습니다. 즉, 각 Syllable은 자음-모음-자음의 형태로 되어 있고, 초성 및 종성에서 구분이 잘 되지 않는 알파벳은 버렸습니다. 마지막으로 ClassroomHash는 ClassroomHashSyllable 세 개의 hyphen-separated 조합입니다.DateNumber 날짜를 나타내는 number 타입입니다.export type ClassroomHashFirst = 'B' | 'H' | 'J' | 'K' | 'L' | 'M' | 'N' | 'P' | 'S' | 'T';
export type ClassroomHashSecond = 'A' | 'E' | 'I' | 'O' | 'U';
export type ClassroomHashThird = 'K' | 'L' | 'M' | 'N' | 'P' | 'S' | 'T' | 'Z';
export type ClassroomHashSyllable = `${ClassroomHashFirst}${ClassroomHashSecond}${ClassroomHashThird}`;
// XXX: TypeScript is too limited to handle ClassroomHash type exactly:
// Expression produces a union type that is too complex to represent.
// export type ClassroomHash =
// `${ClassroomHashSyllable}-${ClassroomHashSyllable}-${ClassroomHashSyllable}`;
export type ClassroomHash = string;
export type DateNumber = number;
classroom/Join 클라이언트가 수업에 접속했을 때 발생하는 Request와 Response입니다.PatchBroadcast 서버에서 수업과 관련된 정보 (멤버, 접속자, 수업 이름 등)가 변화했을 때 패치 목적으로 보내는 Broadcast입니다.import { ClassroomJSON } from '..';
import { ClassroomHash } from './common';
import { DateNumber } from '.';
export namespace SocketClassroom {
export namespace Events {
export interface Request {
'classroom/Join': (params: SocketClassroom.Request.Join) => void;
}
export interface Response {
'classroom/Join': (params: SocketClassroom.Response.Join) => void;
'classroom/PatchBroadcast': (params: SocketClassroom.Broadcast.Patch) => void;
}
}
export namespace Request {
export type Join = JoinRequest;
}
export namespace Response {
export type Join = JoinResponse;
}
export namespace Broadcast {
export type Patch = PatchBroadcast;
}
export interface JoinRequest {
hash: ClassroomHash;
}
export type JoinResponse =
| ({ success: true; isVideoPlaying: boolean; videoTime: DateNumber | null } & ClassroomJSON)
| {
success: false;
reason: typeof JoinFailReason[keyof typeof JoinFailReason];
};
export const JoinFailReason = {
UNAUTHORIZED: -1 as -1,
NOT_MEMBER: -2 as -2,
};
export interface PatchBroadcast {
hash: ClassroomHash;
patch: Partial<ClassroomJSON>;
}
}
voice/음성 채팅을 위한 namespace입니다.
StateChange 음성 채팅 발화자/발화 중지 request/responseStreamSend 발화자일 때 음성 파일 chunk를 보내는 request/responseStateChangeBroadcast 음성 채팅 발화자의 변화를 그 수업의 모든 client에게 보내는 broadcastStreamReceiveBroadcast 음성 채팅 발화자의 음성 파일 chunk를 모든 유저의 main client에게 보내는 broadcastimport { ClassroomHash, DateNumber } from './common';
export namespace SocketVoice {
export namespace Events {
export interface Request {
'voice/StateChange': (params: SocketVoice.Request.StateChange) => void;
'voice/StreamSend': (params: SocketVoice.Request.StreamSend) => void;
}
export interface Response {
'voice/StateChange': (params: SocketVoice.Response.StateChange) => void;
'voice/StreamSend': (params: SocketVoice.Response.StreamSend) => void;
'voice/StateChangeBroadcast': (params: SocketVoice.Broadcast.StateChange) => void;
'voice/StreamReceiveBroadcast': (params: SocketVoice.Broadcast.StreamReceive) => void;
}
}
export namespace Request {
export type StateChange = StateChangeRequest;
export type StreamSend = StreamSendRequest;
}
export namespace Response {
export type StateChange = StateChangeResponse;
export type StreamSend = StreamSendResponse;
}
export namespace Broadcast {
export type StateChange = StateChangeBroadcast;
export type StreamReceive = StreamReceiveBroadcast;
}
/* Request to use or stop using voice chat */
export interface StateChangeRequest {
hash: ClassroomHash;
speaking: boolean;
}
export type StateChangeResponse =
| StateChangeGrantedResponse
| StateChangeDeniedResponse;
export interface StateChangeGrantedResponse {
success: true;
speaking: boolean;
}
export interface StateChangeDeniedResponse {
success: false;
reason: typeof PermissionDeniedReason[keyof typeof PermissionDeniedReason];
}
export const PermissionDeniedReason = {
UNAUTHORIZED: -1 as -1,
NOT_MEMBER: -2 as -2,
SOMEONE_IS_SPEAKING: 0 as 0,
};
export function permissionDeniedReasonAsMessage(
reason: typeof PermissionDeniedReason[keyof typeof PermissionDeniedReason],
): string {
return {
[PermissionDeniedReason.UNAUTHORIZED]: '현재 로그아웃 상태입니다.',
[PermissionDeniedReason.NOT_MEMBER]: '이 수업을 가르치거나 듣는 사람이 아닙니다.',
[PermissionDeniedReason.SOMEONE_IS_SPEAKING]: '누군가 이미 이야기하고 있습니다.',
}[reason];
}
/* Be broadcasted and subscribe voice chat state changes */
export type StateChangeBroadcast =
| StateChangeStartBroadcast
| StateChangeEndBroadcast;
export interface StateChangeStartBroadcast {
hash: ClassroomHash;
userId: string;
speaking: true;
sentAt: DateNumber;
}
export interface StateChangeEndBroadcast {
hash: ClassroomHash;
userId: string;
speaking: false;
reason: typeof StateChangeEndReason[keyof typeof StateChangeEndReason];
sentAt: DateNumber;
}
export const StateChangeEndReason = {
NORMAL: 0 as 0,
SESSION_EXPIRED: -1 as -1,
CONNECTION_LOST: -2 as -2,
INTERRUPTED_BY_INSTRUCTOR: -3 as -3,
};
export function stateChangeEndReasonAsMessage(
reason: typeof StateChangeEndReason[keyof typeof StateChangeEndReason],
): string {
return {
[StateChangeEndReason.NORMAL]: '말하기가 정상적으로 종료되었습니다.',
[StateChangeEndReason.SESSION_EXPIRED]: '세션이 만료되었습니다.',
[StateChangeEndReason.CONNECTION_LOST]: '말씀하시는 분의 접속이 끊겼습니다.',
[StateChangeEndReason.INTERRUPTED_BY_INSTRUCTOR]: '강의자에 의해 말하기가 종료되었습니다.',
}[reason];
}
/* Send voices while talking */
export interface StreamSendRequest {
hash: ClassroomHash;
voices: Voice[];
sequenceIndex: number;
}
export type StreamSendResponse =
| StreamSendGrantedResponse
| StreamSendDeniedResponse;
export interface StreamSendGrantedResponse {
success: true;
sequenceIndex: number;
}
export interface StreamSendDeniedResponse {
success: false;
reason: typeof StreamSendDeniedReason[keyof typeof StreamSendDeniedReason];
}
export const StreamSendDeniedReason = {
UNAUTHORIZED: -1 as -1,
NOT_MEMBER: -2 as -2,
NOT_SPEAKER: -5 as -5,
};
export function streamSendDeniedReasonAsMessage(
reason: typeof StreamSendDeniedReason[keyof typeof StreamSendDeniedReason],
): string {
return {
[StreamSendDeniedReason.UNAUTHORIZED]: '현재 로그아웃 상태입니다.',
[StreamSendDeniedReason.NOT_MEMBER]: '이 수업을 가르치거나 듣는 사람이 아닙니다.',
[StreamSendDeniedReason.NOT_SPEAKER]: '말하기 권한이 없습니다.',
}[reason];
}
/* Received voices */
export interface StreamReceiveBroadcast {
speakerId: string;
voices: Voice[];
sequenceIndex: number;
}
export interface Voice {
type: 'opus' | 'mpeg';
buffer: ArrayBuffer;
}
}
youtube/유튜브 비디오를 공유하기 위한 namespace입니다.
ChangePlayStatus 수업의 instructor의 상태 변화에 따른 동기화 request/response입니다.ChangePlayStatusBroadcast 위의 instructor의 요청을 받은 후 모든 client에 보내는 broadcast입니다.import { YouTubeVideo } from '..';
import { ClassroomHash } from './common';
export namespace SocketYouTube {
export namespace Events {
export interface Request {
'youtube/ChangePlayStatus': (params: SocketYouTube.Request.ChangePlayStatus) => void;
}
export interface Response {
'youtube/ChangePlayStatus': (params: SocketYouTube.Response.ChangePlayStatus) => void;
'youtube/ChangePlayStatusBroadcast': (params: SocketYouTube.Broadcast.ChangePlayStatus) => void;
}
}
export namespace Request {
export type ChangePlayStatus = ChangePlayStatusRequest;
}
export namespace Response {
export type ChangePlayStatus = ChangePlayStatusResponse;
}
export namespace Broadcast {
export type ChangePlayStatus = ChangePlayStatusBroadcast;
}
// send play or stop requset
export interface ChangePlayStatusRequest {
hash: ClassroomHash;
play: boolean;
video: YouTubeVideo | null;
time: number | null;
}
// play status response
export type ChangePlayStatusResponse =
| ChangePlayStatusSuccessResponse
| ChangePlayStatusFailResponse;
export interface ChangePlayStatusSuccessResponse {
success: true;
play: boolean;
}
export interface ChangePlayStatusFailResponse {
success: false;
reason: typeof ChangePlayStatusFailReason[keyof typeof ChangePlayStatusFailReason];
}
export const ChangePlayStatusFailReason = {
UNAUTHORIZED: -1 as -1,
NOT_MEMBER: -2 as -2,
PERMISSION_DENIED: -3 as -3,
};
// play status Broadcast
export interface ChangePlayStatusBroadcast {
hash: ClassroomHash;
play: boolean;
videoId: string | null;
time: number | null;
}
}
총 376회의 commit: https://github.com/2021-fall-cs492c-team-10/monorepo/graphs/commit-activity
총 52개의 리뷰가 완성되었습니다. GitHub PR 탭에서 확인 부탁드립니다.
https://github.com/2021-fall-cs492c-team-10/monorepo/blob/main/README.md
https://github.com/2021-fall-cs492c-team-10/monorepo/blob/main/packages/frontend/README.md
https://github.com/2021-fall-cs492c-team-10/monorepo/blob/main/packages/backend/README.md
https://github.com/2021-fall-cs492c-team-10/monorepo/blob/main/packages/lib/README.md
https://github.com/2021-fall-cs492c-team-10/monorepo/wiki/SSL-서버-여는-법
https://github.com/2021-fall-cs492c-team-10/monorepo/wiki/디자인
https://github.com/2021-fall-cs492c-team-10/monorepo/wiki/레포-구조
https://github.com/2021-fall-cs492c-team-10/monorepo/wiki/레포-세팅
https://github.com/2021-fall-cs492c-team-10/monorepo/wiki/커밋-규칙